#!/usr/bin/ruby

APP_NAME = "calllog2ical.rb"
APP_VERSION = "0.5"

# calllog2ical.rb
#
# An somewhat obnoxious, but effective script that will copy your iPhone's
# call log into a calendar you specify.
#
# It reads the existing calendar and only enters events that are new.
#
# KNOWN ISSUES:
# * The ical entry will only contain the name of the contact that called IF the
#		contact exists in your iPHONE's address book, not your Mac's address book.
# * Only works in Leopard.

# "calllog2ical.rb" is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as published 
# by the Free Software Foundation.
# 
# "calllog2ical.rb" is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.	See the
# GNU General Public License for more details.
# 
# You should have received a copy of the GNU General Public License
# along with "calllog2ical.rb"; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA	 02110-1301	 USA

require 'optparse'

require 'rubygems'
require 'osx/cocoa'
require 'sqlite3'

include OSX

class Numeric
	
	def to_s_as_time
		 NSDate.dateWithTimeIntervalSince1970(self).description
	end
	
end

excludeList = []
excludeIncompleteCalls = false
$verboseMessages = false
leaveDB = false
forceDate = nil
oparse = OptionParser.new {|op|
	op.banner = "Usage : calllog2ical.rb [options] [CALENDARNAME]"
	op.on("-v","--verbose","Run verbosely") {
		$verboseMessages = true
	}
	op.on("-l","--leave-db","Leave the extracted working sqlite files",
	                        "in their temporary location instead of deleting.") {
		leaveDB = true
	}
	op.on("-e NUMBER","--exclude-number NUMBER","Exclude calls from the given number") { |number|
		excludeList << number.delete("^0123456789+")
	}
	op.on("-x EXCLUDE-FILE","--exclude-file EXCLUDE-FILE",
	"Exclude calls listed in EXCLUDE-FILE.",
	"EXCLUDE-FILE should contain a whitespace-",
	"separated list of telephone numbers."
	) { |file|
		File.open(file,"r") do |fp|
			fp.read.split(/\W/).each {|number|
				excludeList << number.delete("^0123456789+")
			}
		end
	}
	op.on("-f DATE", "--force DATE","Force calendar to be updated with all call",
	                                "log events since DATE, deleting all events",
	                                "after DATE before going through the normal",
	                                "read process") {|date|
	  forceDate = NSDate.dateWithNaturalLanguageString(date)
	}
	op.on("-0","Don't copy events with zero duration.",
	"(Incomplete/Unanswered calls)") {
		excludeIncompleteCalls = true
	}
	op.on_tail("-h","-?","Show this message.") {
		puts op
		exit(0)
	}
}

destinationCalendarName = oparse.parse!(ARGV)[0] || "Call Log"


def verbose(str)
	NSLog(str) if $verboseMessages
end

excludeList.each {|n| verbose("Excluding number: #{n}")}

verbose("Loading CalendarStore library")
NSBundle.bundleWithPath("/System/Library/Frameworks/CalendarStore.framework").load
ns_import :CalendarStore

class CLGCall < OSX::NSObject

	attr_accessor :date, :callerID, :contactDB, :duration, :flags
	attr_reader :phoneNumber
	  
	  def phoneNumber=(num)
	    @phoneNumber = CLGCall.clean_phone_number(num)
    end
	
	  def self.clean_phone_number(n) 
	    return n.gsub(/\D/, '')
    end
	
		def contact
		  verbose("CLGCall#contact entered...")
			retVal = @phoneNumber || ""
			if @contactDB && @phoneNumber.length > 0
				if @callerID == -1
				  verbose("Caller ID invalid, will attempt number match")
					 @contactDB.execute("SELECT value, record_id FROM ABMultiValue WHERE property = 3") do |row|
						 pattern = Regexp.compile "\d*#{@phoneNumber}$"
						 thisCleanNum = CLGCall.clean_phone_number(row[0])
						 if row[0] and thisCleanNum.match(pattern)
							 @callerID = row[1]
					   end
					 end
				 end
				 begin
			     query = "SELECT `First`,`Last`,`Organization` FROM ABPerson WHERE ROWID = '#{@callerID}'"
					 @contactDB.execute(query) do |row|
							retVal = if row[0] != nil && row[0] != ''
								row[0] + " " + row[1]
							else
								row[2]
							end
							verbose("Found name %s for contact %s" % [retVal,self])
					 end
				 rescue Exception => e
					 verbose("An error ocurred when trying to look up a name for the number : #{@phoneNumber}")
					 verbose(e.message)
				 end
			end
			retVal ||= @phoneNumber
			retVal = retVal.to_s
			verbose("CLGCall#contact will exit, returning #{retVal}")
			return retVal
		end
		
		def isOutgoing
			flags == 5 ? true : false
		end
		
		def inspect
			"#<CLGCall:"+ ("0x%x" % [object_id]) + " "+ \
			([:date, :phoneNumber,
				:callerID,:duration,:flags].collect {|sym| sym.to_s + "=" + method(sym).call.inspect}).join(" ") + ">"
		end
end

class NSDate
	def inspect
		return description.to_s.inspect
	end
end


calls = []
IPHONE_CALL_LOG_PATH = NSString.stringWithString("Library/CallHistory/call_history.db")
IPHONE_AB_PATH = NSString.stringWithString("Library/AddressBook/AddressBook.sqlitedb")

verbose("Finding calendar named #{destinationCalendarName}")
calendarStore = CalCalendarStore.defaultCalendarStore()
@selectedCalendar = calendarStore.calendars.detect{|cal| cal.title == destinationCalendarName}

if forceDate
  verbose("A force date was given, locating events to delete...")
  deletePred = CalCalendarStore.eventPredicateWithStartDate_endDate_calendars_(forceDate,NSDate.date,[@selectedCalendar])
  eventsToDrop = calendarStore.eventsWithPredicate(deletePred)
  verbose("Deleteing %i events..."%eventsToDrop.size)
  eventsToDrop.each do |event|
    calendarStore.removeEvent_span_error(event,0,nil)
  end
end

unless @selectedCalendar
	verbose("The calendar #{destinationCalendarName} does not exist.")
	exit(1) 
end

# load data from cellphone backup store

dirs = Dir.glob(NSHomeDirectory() + "/Library/Application Support/MobileSync/Backup/*")
#find phone name
phoneDir = dirs[0] # we could enumerate thru each to get every iPhone

unless phoneDir
	verbose(<<-MSG 
	No files were found in /Library/Application Support/MobileSync/Backup/.
	This folder is supposed to contain backup data from your iPhone, but was found to be empty.
	Sync your iphone and run #{APP_NAME} again.
MSG
	)
	exit(1)
end

class DeviceInfoUnavailableError < RuntimeError; end

@phoneName = "Unknown Device"
begin
  verbose("Reading #{phoneDir}/Info.plist for Phone Name")
  raise DeviceInfoUnavailableError unless FileTest.file? phoneDir + "/Info.plist"
  
  infoData = NSData.dataWithContentsOfFile_(phoneDir + "/Info.plist")
  raise DeviceInfoUnavailableError unless infoData
  
  infoPlist = NSPropertyListSerialization.propertyListFromData_mutabilityOption_format_errorDescription_(
    infoData, NSPropertyListImmutable, nil, nil)
  raise DeviceInfoUnavailableError unless infoPlist
  
  @phoneName = infoPlist.objectForKey_("Device Name") 
  verbose("Phone name is #{@phoneName}")
#  end
rescue DeviceInfoUnavailableError
  Nlog("The Info.plist describing device could not be found. " + 
  "Your device's name will not appear in recorded entries.")
end

callSQLData,abSQLData = nil, nil
Dir.glob(phoneDir + "/*.mdbackup") do |testPath|
	#one of these files contains the phone log
	thisData = NSData.dataWithContentsOfFile_(testPath)
	thisPlist = NSPropertyListSerialization.propertyListFromData_mutabilityOption_format_errorDescription_(
		thisData, NSPropertyListImmutable, nil, nil)
	if (thisPlist.objectForKey_("Path") == IPHONE_CALL_LOG_PATH)
		verbose("Found iPhone Call Log db file #{File.basename(testPath)}")
		callSQLData = NSData.dataWithData(thisPlist.objectForKey_("Data"))
	elsif (thisPlist.objectForKey_("Path") == IPHONE_AB_PATH)
		verbose("Found iPhone Address Book db file #{File.basename(testPath)}")
		abSQLData = NSData.dataWithData(thisPlist.objectForKey_("Data"))
	end
end

if (callSQLData && abSQLData) then
	verbose("Address book and Call Log both found")
	Dir.mkdir(NSHomeDirectory() + "/Library/Caches/iPhoneCallLog/") unless
		FileTest.exist?(NSHomeDirectory() + "/Library/Caches/iPhoneCallLog/")
	
	verbose("Extracting sqlite backup data")
	callSQLData.writeToFile_atomically_(NSHomeDirectory() + "/Library/Caches/iPhoneCallLog/call_db.sqlite3", 
	true)
	
	abSQLData.writeToFile_atomically_(NSHomeDirectory() + "/Library/Caches/iPhoneCallLog/addressbook_db.sqlite3", 
	true)
	verbose("Backup data extracted")
		
	verbose("Opening extracted databases")
	calldb = SQLite3::Database.new(NSHomeDirectory() + "/Library/Caches/iPhoneCallLog/call_db.sqlite3")
	contactdb = SQLite3::Database.new(NSHomeDirectory() + "/Library/Caches/iPhoneCallLog/addressbook_db.sqlite3")
	
	verbose("Determining range of events in call log")
	firstBUCallDate, lastBUCallDate = calldb.execute("SELECT MIN(date),MAX(date) FROM call WHERE 1")[0].collect{|v|v.to_i}
	verbose("First event in backup: #{firstBUCallDate.to_s_as_time}")
	verbose("Last event in backup : #{lastBUCallDate.to_s_as_time}")
	
	verbose("Discovering events already in Calendar Store.	Calendar name: #{@selectedCalendar.title}")
	eventPred = CalCalendarStore.eventPredicateWithStartDate_endDate_calendars_(
	NSDate.dateWithTimeIntervalSince1970(firstBUCallDate),NSDate.date,[@selectedCalendar])
	events = calendarStore.eventsWithPredicate(eventPred)
	verbose("Currently #{events.size} events in calendar at given range")
	lastEvent = (events.sort{|a,b| a.startDate.compare(b.startDate)}).last
	verbose("Last event in calendar store: #{lastEvent.inspect}")
	
	lastEventTime = (lastEvent != nil) ? lastEvent.startDate.timeIntervalSince1970 : 0
	verbose("Will read events from backup ocurring after #{lastEventTime.to_s_as_time}")
	
	verbose("Reading call log for new events to add")
	calldb.execute("SELECT * FROM call WHERE date > '#{lastEventTime}'") do |row|
		newCall = CLGCall.alloc.init
		newCall.date = NSDate.dateWithTimeIntervalSince1970(row[2])
		newCall.phoneNumber = row[1]
		newCall.contactDB = contactdb
		newCall.callerID = row[5].to_i
		newCall.flags = row[4].to_i
		newCall.duration = row[3].to_i
		calls << newCall
	end
	calldb.close
end
verbose("Done reading events to add, total events to add: #{calls.size}")

calls.each do |call|
	next if (call.duration == 0 && excludeIncompleteCalls)
	next if (excludeList.include? call.phoneNumber)
	newEvent = CalEvent.alloc.init
	contactString = call.contact
	callDirection = call.isOutgoing ? "to" : "from"
	callerName = (contactString && contactString.length > 0) ? call.contact : "unidentifed caller"
	callType = (call.isOutgoing == false && call.duration == 0) ?	 "Incomplete Call" : "Call"
	newEvent.setTitle("#{callType} #{callDirection} #{callerName} on #{@phoneName}")
	newEvent.setUrl(NSURL.alloc.initWithString("tel:"+ call.phoneNumber.to_s))
	newEvent.setStartDate(call.date)
	newEvent.setEndDate(NSDate.dateWithTimeIntervalSince1970( call.date.timeIntervalSince1970() + call.duration()))
	newEvent.setCalendar(@selectedCalendar)
	newEvent.setNotes("Length of call: " + call.duration.to_s + " seconds")
	verbose("Inserting CalEvent for phone call #{call.inspect}")
	calendarStore.saveEvent_span_error_(newEvent, :CalSpanThisEvent, nil)
end
contactdb.close
verbose("Finished adding events to calendar")

unless leaveDB
  verbose("Throwing away temp files")
  File.unlink(NSHomeDirectory() + "/Library/Caches/iPhoneCallLog/call_db.sqlite3")
  File.unlink(NSHomeDirectory() + "/Library/Caches/iPhoneCallLog/addressbook_db.sqlite3")
end

verbose("exiting now")
exit(0)